AI 응답에 출처와 근거를 연결하는 방법
AI 응답에 출처와 근거를 연결하는 방법
답변 끝에 참고 링크 몇 개를 붙였다고 설명 가능한 응답이 되는 것은 아니다. 사용자는 어느 출처가 어느 문장을 지지하는지, 시스템은 실제로 사용한 구절이 무엇인지 알아야 한다. 이를 위해 출처 문서, 근거 구간, 원자 주장, 답변 표시를 서로 다른 객체로 관리하고 그 관계를 검증해야 한다.
목차
- #출처 목록만으로 부족한 이유
- #출처와 근거와 인용을 구분하기
- #주장과 근거를 다대다 관계로 모델링하기
- #문서 위치는 재현 가능하게 저장하기
- #검색 결과에서 실제 근거를 선택하기
- #인용 번호가 어긋나는 문제를 막기
- #사용자 화면에는 어떻게 보여 줄까
- #근거가 충돌하거나 부족할 때
- #예시 코드로 인용 파이프라인 만들기
- #보안과 개인정보 경계
- #평가와 운영 지표
- #결론
- #관련 노트
출처 목록만으로 부족한 이유
RAG를 처음 구현할 때 흔히 최종 프롬프트에 “참고한 문서 URL을 답변 끝에 표시하라”고 지시한다. 결과는 그럴듯해 보이지만 다음 문제를 남긴다.
- 모델이 읽은 문서와 표시한 문서가 다를 수 있다.
- 문서가 질문과 관련은 있어도 특정 주장을 지원하지 않을 수 있다.
- 하나의 문장에 여러 주장이 섞여 있는데 출처 하나만 붙을 수 있다.
- 모델이 URL이나 문서 제목 자체를 만들어 낼 수 있다.
- 문서가 수정되면 당시 사용한 구절을 재현하기 어렵다.
- 검색 상위 문서를 모두 표시해 실제 사용한 근거를 알 수 없다.
예를 들어 답변이 다음과 같다고 하자.
Pro 요금제는 감사 로그를 1년간 보관하며 모든 지역에서 실시간 내보내기를 지원합니다. [1]
[1] 문서에는 Pro 요금제의 감사 로그 기능만 적혀 있고 보존 기간은 Enterprise 기준, 실시간 내보내기는 일부 지역의 베타 기능일 수 있다. 링크가 존재한다는 사실과 문장 전체가 뒷받침된다는 사실은 다르다.
인용은 답변을 꾸미는 후처리 장식이 아니라 주장과 검증된 근거 사이의 데이터 관계를 사용자에게 표현하는 방식이어야 한다.
출처와 근거와 인용을 구분하기
세 용어를 한 객체로 합치면 구현이 단순해 보이지만 나중에 검증하기 어렵다.
| 개념 | 의미 | 예시 |
|---|---|---|
| Source | 원본 문서나 데이터 원천 | 공식 정책 문서 v7 |
| Evidence | 판정에 실제 사용한 범위 | 4장 2절의 세 문장 |
| Claim | 답변이 참이라고 말하는 최소 사실 | Pro의 보존 기간은 90일이다 |
| Citation | 사용자 화면의 연결 표현 | 문장 뒤 [2] 또는 각주 |
하나의 Source에서 여러 Evidence가 나올 수 있다. 하나의 Claim은 복수 Evidence가 함께 있어야 지원될 수 있다. 반대로 Evidence 하나가 여러 Claim을 지원할 수도 있다. 화면의 [1]은 이 내부 관계를 압축한 표시일 뿐이다.
flowchart LR
S1[Source: 정책 v7] --> E1[Evidence: 보존 기간 표]
S1 --> E2[Evidence: 지역 제한 문단]
S2[Source: 릴리스 노트] --> E3[Evidence: 출시 상태]
E1 --> C1[Claim: 90일 보존]
E2 --> C2[Claim: 서울 리전 지원]
E3 --> C2
C1 --> R[최종 답변]
C2 --> R이 구분은 감사 로그에도 도움이 된다. 잘못된 답변이 발견되면 검색 실패인지, 잘못된 구간 선택인지, 함의 판정 실패인지, UI 매핑 오류인지 나눠 볼 수 있다.
주장과 근거를 다대다 관계로 모델링하기
문장 문자열에 출처 번호를 직접 삽입하기 전에 중간 표현을 만든다.
interface Claim {
id: string;
text: string;
importance: "supporting" | "key" | "critical";
}
interface EvidenceRef {
evidenceId: string;
relation: "supports" | "contradicts" | "context";
confidence: number;
}
interface GroundedClaim extends Claim {
evidence: EvidenceRef[];
verdict: "supported" | "contradicted" | "insufficient";
}
context를 supports와 구분해야 한다. 배경 설명으로 유용한 문서가 주장을 직접 증명한다고 오해하지 않기 위해서다. contradicts도 버리지 않고 보존한다. 서로 다른 버전의 정책이 충돌할 때 최신 문서 선택이나 답변 보류에 사용한다.
다음처럼 문장 하나에 두 주장을 숨기지 않는 편이 좋다.
# 좋지 않은 단위
- text: Pro는 90일 보존하며 서울과 도쿄에서 지원된다.
evidenceIds: [ev-1]
# 검증 가능한 단위
- text: Pro 요금제의 감사 로그 보존 기간은 90일이다.
evidenceIds: [ev-1]
- text: 감사 로그는 서울 리전에서 지원된다.
evidenceIds: [ev-2, ev-3]
- text: 감사 로그는 도쿄 리전에서 지원된다.
evidenceIds: []
verdict: insufficient
최종 표현에서는 다시 자연스러운 문단으로 합칠 수 있다. 다만 합친 뒤 새로운 수식어가 추가되거나 범위가 넓어지지 않았는지 다시 확인해야 한다.
문서 위치는 재현 가능하게 저장하기
URL만 저장하면 문서가 변경되었을 때 같은 근거를 찾지 못한다. 최소한 문서 버전과 실제 구간을 함께 저장한다.
interface Evidence {
id: string;
sourceId: string;
sourceUrl?: string;
sourceVersion: string;
title: string;
excerpt: string;
locator: {
headingPath?: string[];
page?: number;
startOffset?: number;
endOffset?: number;
};
contentHash: string;
retrievedAt: string;
}
HTML은 DOM 구조가 바뀌기 쉬우므로 CSS 선택자 하나만 믿기 어렵다. 제목 경로와 정규화된 텍스트 오프셋, 콘텐츠 해시를 함께 둘 수 있다. PDF는 페이지 번호만으로는 부족할 수 있어 페이지 안의 bounding box나 추출 텍스트 오프셋을 추가한다. 데이터베이스 값이라면 페이지 대신 테이블, 기본 키, 필드, 읽은 버전을 locator로 쓴다.
{
"sourceId": "pricing-policy",
"sourceVersion": "2026-05-03",
"locator": {
"headingPath": ["Audit log", "Retention"],
"startOffset": 184,
"endOffset": 263
},
"contentHash": "sha256:example-only"
}
재현성을 위해 원문 전체를 무기한 저장할 필요는 없다. 저작권, 개인정보, 사내 기밀 정책을 고려해 필요한 구절과 해시만 보존하거나 접근 제어된 저장소에 분리한다.
검색 결과에서 실제 근거를 선택하기
검색기가 반환한 청크는 근거 후보이지 확정 근거가 아니다. 검색 점수는 질의와 텍스트가 가깝다는 뜻이지 주장을 논리적으로 지원한다는 뜻이 아니다.
flowchart TD
Q[Claim] --> R[후보 검색]
R --> F[권위·버전·유효기간 필터]
F --> N[중복 및 파생 출처 정리]
N --> J[지원·반박·불충분 판정]
J --> K[실제로 사용한 span 선택]
K --> C[Claim-Evidence 연결 저장]판정 프롬프트를 사용한다면 모델이 출처 ID를 새로 만들지 못하게 허용 목록을 넘긴다.
const candidates = [
{ id: "ev-11", excerpt: "..." },
{ id: "ev-12", excerpt: "..." },
];
const judgment = await verifier.judge({
claim: "Pro 요금제의 보존 기간은 90일이다.",
candidates,
allowedEvidenceIds: candidates.map(({ id }) => id),
labels: ["supports", "contradicts", "insufficient"],
});
if (!judgment.evidenceIds.every((id) =>
candidates.some((item) => item.id === id)
)) {
throw new Error("UNKNOWN_EVIDENCE_ID");
}
후보 청크 전체를 인용하지 말고 주장을 지원하는 최소 구간을 고른다. 너무 짧으면 조건이 잘리고, 너무 길면 사용자가 근거를 찾기 어렵다. 문장 단위로 시작하되 단,, 다만, 표의 헤더처럼 의미를 제한하는 주변 문맥을 함께 포함한다.
인용 번호가 어긋나는 문제를 막기
모델이 답변 문자열 안에 [1], [2]를 직접 생성하게 하면 번호와 출처가 어긋나기 쉽다. 문단 수정이나 스트리밍 재시도 중 번호가 바뀌기도 한다. 안정적인 방법은 모델이 의미 있는 ID를 포함한 중간 구조를 만들고 애플리케이션이 번호를 렌더링하는 것이다.
interface AnswerSegment {
text: string;
claimIds: string[];
}
interface AnswerDocument {
segments: AnswerSegment[];
claims: GroundedClaim[];
evidence: Evidence[];
}
function renderCitations(document: AnswerDocument): string {
const numberByEvidence = new Map<string, number>();
let next = 1;
return document.segments.map((segment) => {
const evidenceIds = segment.claimIds
.flatMap((claimId) =>
document.claims.find((claim) => claim.id === claimId)
?.evidence.filter((ref) => ref.relation === "supports")
.map((ref) => ref.evidenceId) ?? []
)
.filter((id, index, all) => all.indexOf(id) === index);
const marks = evidenceIds.map((id) => {
if (!numberByEvidence.has(id)) numberByEvidence.set(id, next++);
return "[" + numberByEvidence.get(id) + "]";
});
return segment.text + (marks.length ? " " + marks.join("") : "");
}).join("\\n\\n");
}
번호는 표시 계층의 관심사다. 내부에서는 안정적인 evidenceId를 유지한다. 답변 순서가 바뀌어도 연결 관계가 사라지지 않는다.
사용자 화면에는 어떻게 보여 줄까
모든 문장 뒤에 인용을 붙이면 정확해 보이지만 읽기 어려울 수 있다. 위험도와 문서 성격에 따라 표현을 달리한다.
| 상황 | 권장 표현 |
|---|---|
| 짧은 사실 답변 | 주장 바로 뒤 각주 |
| 긴 설명 | 문단별 핵심 주장에 각주 |
| 수치·날짜·정책 | 해당 값 바로 뒤 근거와 기준일 |
| 서로 충돌하는 자료 | 두 근거와 차이를 본문에 명시 |
| 내부 업무 도구 | 펼쳐 볼 수 있는 근거 패널 |
| 실행 결과 | 거래 ID, 실행 시각, 확인 상태 |
인용을 클릭했을 때 문서 첫 화면만 열지 말고 가능하면 해당 제목이나 구절을 강조한다. 사용자에게 보여 주는 짧은 발췌문도 실제 저장된 Evidence와 같아야 한다.
“신뢰도 92%”라는 숫자는 산정 방식과 보정 데이터가 없으면 오해를 만든다. 사용자가 원문 구절, 기준 시각, 출처 종류를 확인하게 하는 편이 더 실용적이다.
근거가 충돌하거나 부족할 때
최신 공식 문서와 오래된 블로그가 충돌한다면 단순 다수결을 쓰면 안 된다. 출처 권위, 발행 시각, 적용 버전, 지역과 요금제 범위를 비교한다.
function chooseEvidence(items: EvidenceCandidate[], claim: Claim) {
return items
.filter((item) => item.appliesTo(claim))
.sort((a, b) =>
b.authorityRank - a.authorityRank ||
b.effectiveAt.getTime() - a.effectiveAt.getTime()
);
}
두 공식 문서가 실제로 충돌하거나 필요한 범위를 알 수 없다면 하나를 임의로 고르지 않는다.
현재 공식 가격표에는 90일로 표시되지만 이전 지원 문서에는 1년으로 적혀 있습니다. 질문하신 계약의 적용 버전을 확인할 수 없어 보존 기간을 단정하기 어렵습니다.
근거가 없을 때도 출처를 꾸며 내지 말고 그 사실을 응답 상태로 다룬다. 자세한 검증 정책은 환각을 없애기보다 검증 경로를 설계하기와 이어진다.
예시 코드로 인용 파이프라인 만들기
다음 코드는 실제 저장소와 무관한 재구성 예시다.
async function createGroundedAnswer(question: string) {
const plan = await planner.create(question);
const draftClaims = await claimGenerator.generate(plan);
const groundedClaims = await Promise.all(
draftClaims.map(async (claim): Promise<GroundedClaim> => {
const candidates = await retriever.search(claim.text);
const valid = candidates.filter(isAuthoritativeAndFresh);
const result = await evidenceJudge.evaluate(claim, valid);
return {
...claim,
evidence: result.links,
verdict: result.verdict,
};
}),
);
const criticalFailure = groundedClaims.some(
(claim) =>
claim.importance === "critical" &&
claim.verdict !== "supported",
);
if (criticalFailure) {
return answerPolicy.requestMoreInformation(groundedClaims);
}
const document = await composer.compose({
question,
claims: groundedClaims.filter(
(claim) => claim.verdict === "supported",
),
});
validateNoUnknownClaim(document, groundedClaims);
validateAllEvidenceIds(document);
return renderCitations(document);
}
여기에는 중요한 두 검사가 있다. validateNoUnknownClaim은 작성기가 검증되지 않은 새 주장을 끼워 넣지 못하게 한다. validateAllEvidenceIds는 존재하지 않거나 현재 요청 범위 밖의 근거가 표시되는 것을 막는다.
테스트도 최종 문자열 스냅샷만 비교하지 않고 관계를 검사한다.
it("지원되지 않은 핵심 주장에는 인용을 붙이지 않는다", async () => {
const result = await pipeline.run(fixtures.conflictingPolicy);
const retention = result.claims.find(
(claim) => claim.id === "claim-retention",
);
expect(retention?.verdict).toBe("insufficient");
expect(result.rendered).not.toContain("[retention-source]");
expect(result.decision).toBe("ask_for_contract_version");
});
보안과 개인정보 경계
근거 표시 기능은 내부 데이터 유출 경로가 될 수 있다. 모델이 답변할 권한과 원문 전체를 보여 줄 권한은 같지 않다.
- 검색과 근거 조회 모두 사용자·테넌트 범위로 제한한다.
- 원문에 개인정보가 있으면 발췌문을 마스킹한다.
- 내부 URL이나 스토리지 경로를 외부 응답에 그대로 노출하지 않는다.
- 근거 ID를 순차 숫자로 만들어 다른 사용자의 자료를 추측하게 하지 않는다.
- 답변 캐시는 권한 범위와 source version을 키에 포함한다.
- 접근 권한이 사라지면 이전 인용 패널도 열리지 않게 한다.
async function presentEvidence(
viewer: Viewer,
evidenceId: string,
) {
const evidence = await evidenceStore.get(evidenceId);
await authorization.assertCanReadSource(viewer, evidence.sourceId);
return {
title: evidence.title,
excerpt: redactSensitiveFields(evidence.excerpt),
locator: publicLocator(evidence.locator),
};
}
답변 생성 시 내부 문서를 사용했다는 이유만으로 사용자가 그 문서 원문을 볼 수 있는 것은 아니다. 답변 가능 범위와 원문 공개 범위를 별도로 설계한다.
평가와 운영 지표
인용의 품질은 링크 개수로 평가하지 않는다.
| 지표 | 질문 |
|---|---|
| citation correctness | 표시된 근거가 주장을 실제로 지원하는가 |
| citation completeness | 검증이 필요한 핵심 주장에 근거가 빠지지 않았는가 |
| source quality | 요구되는 권위와 최신성을 만족하는가 |
| locator accuracy | 클릭 시 실제 지원 구간에 도달하는가 |
| claim coverage | 답변의 사실 주장 중 검증된 비율은 얼마인가 |
| stale citation rate | 오래된 문서를 현재 근거로 사용한 비율은 얼마인가 |
| citation leakage rate | 권한 없는 원문 정보가 노출되는가 |
평가 케이스에는 같은 문서의 다른 문단, 관련 있지만 지원하지 않는 청크, 조건절이 잘린 청크, 구버전 문서, 서로 충돌하는 공식 자료를 포함한다. 실제 장애는 LLM 평가셋을 실제 실패 사례로 만드는 방법의 형식으로 회귀 케이스에 남긴다.
결론
출처 링크 목록은 시작점일 뿐이다. 신뢰 가능한 AI 응답을 만들려면 원본 문서에서 실제 근거 구간을 선택하고, 그 구간을 원자 주장과 연결한 뒤, 애플리케이션이 그 관계를 인용으로 렌더링해야 한다.
Source, Evidence, Claim, Citation을 분리하면 검색 오류와 표시 오류를 구분할 수 있고, 문서 변경에도 당시 판단을 재현할 수 있다. 또한 근거가 부족하거나 충돌할 때 억지로 출처를 붙이는 대신 답변을 제한할 수 있다.
인용의 목적은 답변을 더 그럴듯하게 보이게 만드는 것이 아니다. 사용자와 운영자가 이 문장을 왜 믿어도 되는지 직접 따라갈 수 있는 경로를 제공하는 것이다.